用 React + Redux 做一個 todo list 吧


Posted by Christy on 2021-12-29

本文為 Lidemy W23 作業一「用 React Redux 做一個 todo list」的實作過程

零、前情提要

看完 Dan 哥的 Presentational and Container Components 以後,決定要用 useDispatch() 串接 React Redux,因此把之前練習 connect() 的方式改回來

先不用 component 包起來,而是直接全部寫在 App.js 裡,等實作功能都完成後,再去優化

  • reducers 裡面都是 pure function,不會在裡面呼叫 API,也不會在裡面寫 local storage,唯一會做的事就是「回傳一個新的狀態」。

  • action 只是一個 js 的物件而已

  • store 的資料跟 dispatch action 分開的好處是,方便做測試

  • global 的資料才適合存在 redux 裡面,其他的放在 component 就好了;例如使用者資料,就是每個 component 都會用到的東西

資料夾結構:

  • SRC folder

    • redux folder

      • reducers folder

        • index.js: 利用 combineReducers 把 reducers 放在一起

        • todos.js: todo 相關的邏輯,例如新增、刪除等

        • users.js: 使用者相關的邏輯,例如新增使用者

      • actions.js: action creators 的函式們

      • actionTypes.js: action constants 的規範字元

      • selectors.js: 把資料從 store 取出來

      • store.js: 創建 store

    • App.js

    • index.js

reducers folder > index.js

// reducers folder > index.js

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';

export default combineReducers({
  todoState: todos,
  users
});

todos.js

// todos.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }

    case CHECK_TODO: {
      return {
        ...state,
        todos: state.todos.map((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}

actions.js: action 只是一個 js 的物件而已

// actions.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO, ADD_USER } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}

export function deleteTodo(id) {
  return {
    type: DELETE_TODO,
    payload: {
      id
    }
  };
}

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}

export function addUser(name) {
  return {
    type: ADD_USER,
    payload: {
      name
    }
  };
}

actionTypes.js

// actionTypes.js

export const ADD_TODO = 'add_todo';
export const DELETE_TODO = 'delete_todo';
export const CHECK_TODO = 'check_todo';

selectors.js

// selectors.js

export const selectTodos = (store) => store.todoState.todos;

store.js

// store.js

import { createStore } from 'redux';
import rootReducer from './reducers';

export default createStore(rootReducer);

index.js

// index.js

import ReactDOM from 'react-dom';
import App from './App';
import { Provider } from 'react-redux';
import store from './redux/store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

App.js (新增、刪除跟著影片一起做了,完成 / 未完成做到一半)

// App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 0 auto;
  text-align: center;
  padding: 30px;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 500px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            dispatch(addTodo(value));
            setValue('');
          }}>
          add todo
        </AddButton>
      </CreateToto>
      <TodoList>
        {todos.map((todo) => (
          <TodoItem $isDone key={todo.id} todo-id={todo.id}>
            {todo.name}
            <ButtonWrapper>
              <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
              <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>Done</CheckButton>
            </ButtonWrapper>
          </TodoItem>
        ))}
      </TodoList>
    </TodoWrapper>
  );
}

實作 todo 主要會用到 todos.js、actions.js、actionTypes.js、App.js,讓我們開始吧

一、新增、刪除 todo 功能

  1. 規範字元 export const ADD_TODO = 'add_todo';

  2. 把要做的事在 action 裡面寫成函式

import { ADD_TODO } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}
  1. todos 裡面管邏輯,把要做的事寫好
import { ADD_TODO, DELETE_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}
  1. error log: react.development.js:1476 Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons 下略

不能在 reducer 的函式裡面用 useRef(),會出現上面的錯誤訊息

二、完成 / 未完成

  1. 定義規範字元 export const CHECK_TODO = 'check_todo';

  2. 行動

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}
  1. reducer
case CHECK_TODO: {
  return {
    ...state,
    todos: state.todos.map((todo) => {
      if (todo.id !== action.payload.id) return todo;
      return {
        ...todo,
        isDone: !todo.isDone
      };
    })
  };
}
  1. App.js

    • 強迫換行:word-wrap: break-word;
// App.js

// css
const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

// logic
<TodoList>
  {todos.map((todo) => (
    <TodoItem key={todo.id}>
      <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
      <ButtonWrapper>
        <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
        <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
          {todo.isDone ? 'Undone' : 'Done'}
        </CheckButton>
      </ButtonWrapper>
    </TodoItem>
  ))}
</TodoList>

三、清空完成的 todo

跟刪除類似,如果 todo.isDone !== true 就留下來

一樣的步驟:

a. 規範字元

export const CLEAR_COMPLETED_TODO = 'clear_completed_todo';

b. 關注 action 的資料結構

export function clearCompletedTodo(id) {
  return {
    type: CLEAR_COMPLETED_TODO,
    payload: {
      id
    }
  };
}

c. reducer 裡面放要做的事

case CLEAR_COMPLETED_TODO: {
  return {
    ...state,
    todos: state.todos.filter((todo) => todo.isDone !== true)
  };
}

d. App.js 點擊事件

<ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
  Clear Completed
</ClearCompleted>

四、篩選 todo(全部、未完成、已完成)

1. 這個用原本 state 的方式去做了

這個我沒有用 dispatch(),而是用 filter 的方式做,但是什麼時候要用 dispatch() 什麼時候不要用啊?就是狀態是 global 時候用 dispatch() 的方式,其他時候用 state 就好了

// App.js Filter state 寫法

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo, clearCompletedTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const SelectTodo = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');
  const [filter, setFilter] = useState('all');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const filterAll = () => {
    setFilter('all');
  };

  const filterDone = () => {
    setFilter('done');
  };

  const filterUndone = () => {
    setFilter('undone');
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            if (value) {
              dispatch(addTodo(value));
            }
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>
      <SelectTodo>
        <TodoAllButton onClick={filterAll}>All</TodoAllButton>
        <TodoActiveButton onClick={filterUndone}>Active</TodoActiveButton>
        <TodoCompletedButton onClick={filterDone}>Completed</TodoCompletedButton>
      </SelectTodo>
      <TodoList>
        {todos
          .filter((todo) => {
            if (filter === 'all') return todo;
            return filter === 'done' ? todo.isDone : !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

小優化 1:如果輸入框沒有填或者是有空白,就不給輸入:if (!value.trim()) return

小優化 2:輸入框 enter 自動輸入

const handleKeyPress = useCallback((e) => {
  if (!value.trim()) return;
  if (e.key === 'Enter') {
    dispatch(addTodo(e.target.value));
    setValue('');
  }
});

2. 試著把 filter 變成狀態放在 reducers 裡面,用這個方式篩選 todo

原本用下次的做法,但跟我一開始的想法不太一樣,做了一天還是做不出來,我覺得我還是只會做「畫面改變三次就是三種狀態」的後來那一個


版本 A: 這裡是我寫不出來的

a. actionTypes: export const SET_FILTER = 'set_filter';

b. actions:

export function filterTodo(filter) {
  return {
    type: SET_FILTER,
    payload: {
      filter
    }
  };
}

c. reducers folder > filters:

import { SET_FILTER } from '../actionTypes';

const initialFilter = {
  filters: 'All'
};

export default function filterReducer(state = initialFilter, action) {
  switch (action.type) {
    case SET_FILTER: {
      return action.payload.filter;
    }

    default: {
      return state;
    }
  }
}

d. reducers folder > index.js:

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';
import filters from './filters';

export default combineReducers({
  todoState: todos,
  filters,
  users
});

e. selectors:

export const selectFilters = (store) => store.filters.filters;

f. App.js

f.1 const filters = useSelector(selectFilters);

f.2 我想要實作的是:在 filter 這個 store 裡面,有三種情形,全部、完成、未完成,遇到的困難是:在這裡面我不知道怎麼寫「把 todo 先篩選後顯示」也就是 .filter(...).map(...) 跟之前用的方法一樣,我不知道該怎麼做

reducers 的 filter 裡面,好像不能夠把動作一次寫完?因為 App.js 裡面的 .map() 才是主要的顯示關鍵


版本 B: 把 filter 的三種狀態放在 reducers 裡面

a. actionTypes:

export const FILTER_ALL = 'filter_all';
export const FILTER_DONE = 'filter_done';
export const FILTER_UNDONE = 'filter_undone';

b. actions:

export function filterAll() {
  return {
    type: FILTER_ALL
  };
}

export function filterDone() {
  return {
    type: FILTER_DONE
  };
}

export function filterUndone() {
  return {
    type: FILTER_UNDONE
  };
}

c. reducers > todos

case FILTER_ALL: {
  return {
    ...state,
    filter: 'all'
  };
}

case FILTER_DONE: {
  return {
    ...state,
    filter: 'done'
  };
}

case FILTER_UNDONE: {
  return {
    ...state,
    filter: 'undone'
  };
}

d. selectors:

export const selectFilters = (store) => store.todoState.filter;

e. App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos, selectFilters } from './redux/selectors';
import {
  addTodo,
  deleteTodo,
  checkTodo,
  clearCompletedTodo,
  filterAll,
  filterDone,
  filterUndone
} from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const FilterTodoWrapper = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;
  text-align: left;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const filters = useSelector(selectFilters);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const handleKeyPress = (e) => {
    if (!value.trim()) return;
    if (e.key === 'Enter') {
      dispatch(addTodo(value));
      setValue('');
    }
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} onKeyPress={handleKeyPress} />
        <AddButton
          onClick={() => {
            if (!value.trim()) return;
            dispatch(addTodo(value));
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>

      <FilterTodoWrapper>
        <ButtonWrapper>
          <TodoAllButton onClick={() => dispatch(filterAll('all'))}>All</TodoAllButton>
          <TodoActiveButton onClick={() => dispatch(filterUndone('undone'))}>
            Active
          </TodoActiveButton>
          <TodoCompletedButton onClick={() => dispatch(filterDone('done'))}>
            Completed
          </TodoCompletedButton>
        </ButtonWrapper>
      </FilterTodoWrapper>

      <TodoList>
        {todos
          .filter((todo) => {
            if (filters === 'all') return todo;
            if (filters === 'done') return todo.isDone;
            return !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

註:上傳作業前有稍微優化一下程式碼,但是忘記 react 客家精神避免重複 render 沒有用 memo, useCallback 那些包好包滿...










Related Posts

Ubuntu 網路設定

Ubuntu 網路設定

淺談 ORM

淺談 ORM

自駕車 Sensor Fusion in Visual Perception 簡介

自駕車 Sensor Fusion in Visual Perception 簡介


Comments